diff --git a/lib/package.json b/lib/package.json index 2baf96104..b1a99da77 100644 --- a/lib/package.json +++ b/lib/package.json @@ -1,41 +1,42 @@ { "name": "lib", "version": "0.0.1", "type": "module", "private": true, "license": "BSD-3-Clause", "scripts": { "clean": "rm -rf node_modules/" }, "devDependencies": { "@babel/core": "^7.12.10", "@babel/plugin-proposal-class-properties": "^7.10.4", "@babel/plugin-proposal-nullish-coalescing-operator": "^7.10.4", "@babel/plugin-proposal-object-rest-spread": "^7.11.0", "@babel/plugin-proposal-optional-chaining": "^7.11.0", "@babel/plugin-transform-runtime": "^7.11.5", "@babel/preset-flow": "^7.9.0", "@babel/preset-react": "^7.9.1", "flow-bin": "^0.122.0", "flow-typed": "^3.2.1" }, "dependencies": { "dateformat": "^3.0.3", "emoji-regex": "^9.2.0", "fast-json-stable-stringify": "^2.0.0", "file-type": "^12.3.0", "invariant": "^2.2.4", "lodash": "^4.17.19", "prop-types": "^15.7.2", "react": "16.13.1", "react-redux": "^7.1.1", "reselect": "^4.0.0", "reselect-map": "^1.0.5", "string-hash": "^1.1.3", "tinycolor2": "^1.4.1", "tokenize-text": "^1.1.3", "url-parse-lax": "^3.0.0", "util-inspect": "^0.1.8", + "simple-markdown": "^0.7.2", "utils-copy-error": "^1.0.1" } } diff --git a/lib/shared/message-utils.js b/lib/shared/message-utils.js index b342f95c6..82c4fff52 100644 --- a/lib/shared/message-utils.js +++ b/lib/shared/message-utils.js @@ -1,396 +1,399 @@ // @flow import invariant from 'invariant'; import _maxBy from 'lodash/fp/maxBy'; +import { type ParserRules } from 'simple-markdown'; import { multimediaMessagePreview } from '../media/media-utils'; import { userIDsToRelativeUserInfos } from '../selectors/user-selectors'; import type { PlatformDetails } from '../types/device-types'; import type { Media } from '../types/media-types'; import { type MessageInfo, type RawMessageInfo, type RobotextMessageInfo, type PreviewableMessageInfo, type RawMultimediaMessageInfo, type MessageData, type MessageType, type MessageTruncationStatus, type MultimediaMessageData, type MessageStore, type ComposableMessageInfo, messageTypes, messageTruncationStatus, type RawComposableMessageInfo, } from '../types/message-types'; import type { ImagesMessageData } from '../types/messages/images'; import type { MediaMessageData } from '../types/messages/media'; import { type ThreadInfo } from '../types/thread-types'; import type { RelativeUserInfo, UserInfos } from '../types/user-types'; import { trimText } from '../utils/text-utils'; import { codeBlockRegex } from './markdown'; import { messageSpecs } from './messages/message-specs'; import { stringForUser } from './user-utils'; // Prefers localID function messageKey(messageInfo: MessageInfo | RawMessageInfo): string { if (messageInfo.localID) { return messageInfo.localID; } invariant(messageInfo.id, 'localID should exist if ID does not'); return messageInfo.id; } // Prefers serverID function messageID(messageInfo: MessageInfo | RawMessageInfo): string { if (messageInfo.id) { return messageInfo.id; } invariant(messageInfo.localID, 'localID should exist if ID does not'); return messageInfo.localID; } function robotextForUser(user: RelativeUserInfo): string { if (user.isViewer) { return 'you'; } else if (user.username) { return `<${encodeURI(user.username)}|u${user.id}>`; } else { return 'anonymous'; } } function robotextForUsers(users: RelativeUserInfo[]): string { if (users.length === 1) { return robotextForUser(users[0]); } else if (users.length === 2) { return `${robotextForUser(users[0])} and ${robotextForUser(users[1])}`; } else if (users.length === 3) { return ( `${robotextForUser(users[0])}, ${robotextForUser(users[1])}, ` + `and ${robotextForUser(users[2])}` ); } else { return ( `${robotextForUser(users[0])}, ${robotextForUser(users[1])}, ` + `and ${users.length - 2} others` ); } } function encodedThreadEntity(threadID: string, text: string): string { return `<${text}|t${threadID}>`; } function robotextForMessageInfo( messageInfo: RobotextMessageInfo, threadInfo: ThreadInfo, ): string { const creator = robotextForUser(messageInfo.creator); const messageSpec = messageSpecs[messageInfo.type]; invariant( messageSpec.robotext, `we're not aware of messageType ${messageInfo.type}`, ); return messageSpec.robotext(messageInfo, creator, { encodedThreadEntity, robotextForUsers, robotextForUser, threadInfo, }); } function robotextToRawString(robotext: string): string { return decodeURI(robotext.replace(/<([^<>|]+)\|[^<>|]+>/g, '$1')); } function createMessageInfo( rawMessageInfo: RawMessageInfo, viewerID: ?string, userInfos: UserInfos, threadInfos: { [id: string]: ThreadInfo }, ): ?MessageInfo { const creatorInfo = userInfos[rawMessageInfo.creatorID]; if (!creatorInfo) { return null; } const creator = { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }; const createRelativeUserInfos = (userIDs: $ReadOnlyArray) => userIDsToRelativeUserInfos(userIDs, viewerID, userInfos); const createMessageInfoFromRaw = (rawInfo: RawMessageInfo) => createMessageInfo(rawInfo, viewerID, userInfos, threadInfos); const messageSpec = messageSpecs[rawMessageInfo.type]; return messageSpec.createMessageInfo(rawMessageInfo, creator, { threadInfos, createMessageInfoFromRaw, createRelativeUserInfos, }); } function sortMessageInfoList( messageInfos: T[], ): T[] { return messageInfos.sort((a: T, b: T) => b.time - a.time); } function rawMessageInfoFromMessageData( messageData: MessageData, id: string, ): RawMessageInfo { const messageSpec = messageSpecs[messageData.type]; invariant( messageSpec.rawMessageInfoFromMessageData, `we're not aware of messageType ${messageData.type}`, ); return messageSpec.rawMessageInfoFromMessageData(messageData, id); } function mostRecentMessageTimestamp( messageInfos: RawMessageInfo[], previousTimestamp: number, ): number { if (messageInfos.length === 0) { return previousTimestamp; } return _maxBy('time')(messageInfos).time; } function messageTypeGeneratesNotifs(type: MessageType) { return messageSpecs[type].generatesNotifs; } function splitRobotext(robotext: string) { return robotext.split(/(<[^<>|]+\|[^<>|]+>)/g); } const robotextEntityRegex = /<([^<>|]+)\|([^<>|]+)>/; function parseRobotextEntity(robotextPart: string) { const entityParts = robotextPart.match(robotextEntityRegex); invariant(entityParts && entityParts[1], 'malformed robotext'); const rawText = decodeURI(entityParts[1]); const entityType = entityParts[2].charAt(0); const id = entityParts[2].substr(1); return { rawText, entityType, id }; } function usersInMessageInfos( messageInfos: $ReadOnlyArray, ): string[] { const userIDs = new Set(); for (let messageInfo of messageInfos) { if (messageInfo.creatorID) { userIDs.add(messageInfo.creatorID); } else if (messageInfo.creator) { userIDs.add(messageInfo.creator.id); } } return [...userIDs]; } function combineTruncationStatuses( first: MessageTruncationStatus, second: ?MessageTruncationStatus, ): MessageTruncationStatus { if ( first === messageTruncationStatus.EXHAUSTIVE || second === messageTruncationStatus.EXHAUSTIVE ) { return messageTruncationStatus.EXHAUSTIVE; } else if ( first === messageTruncationStatus.UNCHANGED && second !== null && second !== undefined ) { return second; } else { return first; } } function shimUnsupportedRawMessageInfos( rawMessageInfos: $ReadOnlyArray, platformDetails: ?PlatformDetails, ): RawMessageInfo[] { if (platformDetails && platformDetails.platform === 'web') { return [...rawMessageInfos]; } return rawMessageInfos.map((rawMessageInfo) => { const { shimUnsupportedMessageInfo } = messageSpecs[rawMessageInfo.type]; if (shimUnsupportedMessageInfo) { return shimUnsupportedMessageInfo(rawMessageInfo, platformDetails); } return rawMessageInfo; }); } function messagePreviewText( messageInfo: PreviewableMessageInfo, threadInfo: ThreadInfo, ): string { if ( messageInfo.type === messageTypes.IMAGES || messageInfo.type === messageTypes.MULTIMEDIA ) { const creator = stringForUser(messageInfo.creator); const preview = multimediaMessagePreview(messageInfo); return `${creator} ${preview}`; } return robotextToRawString(robotextForMessageInfo(messageInfo, threadInfo)); } type MediaMessageDataCreationInput = $ReadOnly<{ threadID: string, creatorID: string, media: $ReadOnlyArray, localID?: ?string, time?: ?number, ... }>; function createMediaMessageData( input: MediaMessageDataCreationInput, ): MultimediaMessageData { let allMediaArePhotos = true; const photoMedia = []; for (let singleMedia of input.media) { if (singleMedia.type === 'video') { allMediaArePhotos = false; break; } else { photoMedia.push(singleMedia); } } const { localID, threadID, creatorID } = input; const time = input.time ? input.time : Date.now(); let messageData; if (allMediaArePhotos) { messageData = ({ type: messageTypes.IMAGES, threadID, creatorID, time, media: photoMedia, }: ImagesMessageData); } else { messageData = ({ type: messageTypes.MULTIMEDIA, threadID, creatorID, time, media: input.media, }: MediaMessageData); } if (localID) { messageData.localID = localID; } return messageData; } type MediaMessageInfoCreationInput = $ReadOnly<{ ...$Exact, id?: ?string, }>; function createMediaMessageInfo( input: MediaMessageInfoCreationInput, ): RawMultimediaMessageInfo { const messageData = createMediaMessageData(input); const messageSpec = messageSpecs[messageData.type]; return messageSpec.rawMessageInfoFromMessageData(messageData, input.id); } function stripLocalID(rawMessageInfo: RawComposableMessageInfo) { const { localID, ...rest } = rawMessageInfo; return rest; } function stripLocalIDs( input: $ReadOnlyArray, ): RawMessageInfo[] { const output = []; for (const rawMessageInfo of input) { if (rawMessageInfo.localID) { invariant( rawMessageInfo.id, 'serverID should be set if localID is being stripped', ); output.push(stripLocalID(rawMessageInfo)); } else { output.push(rawMessageInfo); } } return output; } // Normally we call trim() to remove whitespace at the beginning and end of each // message. However, our Markdown parser supports a "codeBlock" format where the // user can indent each line to indicate a code block. If we match the // corresponding RegEx, we'll only trim whitespace off the end. function trimMessage(message: string) { message = message.replace(/^\n*/, ''); return codeBlockRegex.exec(message) ? message.trimEnd() : message.trim(); } function createMessageReply(message: string) { // add `>` to each line to include empty lines in the quote const quotedMessage = message.replace(/^/gm, '> '); return quotedMessage + '\n\n'; } function getMostRecentNonLocalMessageID( threadInfo: ThreadInfo, messageStore: MessageStore, ): ?string { const thread = messageStore.threads[threadInfo.id]; return thread?.messageIDs.find((id) => !id.startsWith('local')); } function getMessageTitle( messageInfo: ComposableMessageInfo | RobotextMessageInfo, threadInfo: ThreadInfo, + markdownRules: ParserRules, ): string | null { const { messageTitle } = messageSpecs[messageInfo.type]; if (!messageTitle) { return null; } const messageInfoWithNoViewer = { ...messageInfo, creator: { ...messageInfo.creator, isViewer: false }, }; const name = messageTitle({ messageInfo: messageInfoWithNoViewer, threadInfo, + markdownRules, }); return trimText(name, 30); } export { messageKey, messageID, robotextForMessageInfo, robotextToRawString, createMessageInfo, sortMessageInfoList, rawMessageInfoFromMessageData, mostRecentMessageTimestamp, messageTypeGeneratesNotifs, splitRobotext, parseRobotextEntity, usersInMessageInfos, combineTruncationStatuses, shimUnsupportedRawMessageInfos, messagePreviewText, createMediaMessageData, createMediaMessageInfo, stripLocalIDs, trimMessage, createMessageReply, getMostRecentNonLocalMessageID, getMessageTitle, }; diff --git a/lib/shared/messages/message-spec.js b/lib/shared/messages/message-spec.js index 038e84e23..be0545530 100644 --- a/lib/shared/messages/message-spec.js +++ b/lib/shared/messages/message-spec.js @@ -1,92 +1,95 @@ // @flow +import { type ParserRules } from 'simple-markdown'; + import type { PlatformDetails } from '../../types/device-types'; import type { Media } from '../../types/media-types'; import type { MessageInfo, RawComposableMessageInfo, RawMessageInfo, RawRobotextMessageInfo, RobotextMessageInfo, } from '../../types/message-types'; import type { RawUnsupportedMessageInfo } from '../../types/messages/unsupported'; import type { NotifTexts } from '../../types/notif-types'; import type { ThreadInfo, ThreadType } from '../../types/thread-types'; import type { RelativeUserInfo } from '../../types/user-types'; export type MessageSpec = {| +messageContent?: (data: Data) => string | null, +messageTitle?: ({| +messageInfo: Info, +threadInfo: ThreadInfo, + +markdownRules: ParserRules, |}) => string, +rawMessageInfoFromRow?: ( row: Object, params: {| +localID: string, +media?: $ReadOnlyArray, +derivedMessages: $ReadOnlyMap< string, RawComposableMessageInfo | RawRobotextMessageInfo, >, |}, ) => ?RawInfo, +createMessageInfo: ( rawMessageInfo: RawInfo, creator: RelativeUserInfo, params: {| +threadInfos: {| [id: string]: ThreadInfo |}, +createMessageInfoFromRaw: (rawInfo: RawMessageInfo) => MessageInfo, +createRelativeUserInfos: ( userIDs: $ReadOnlyArray, ) => RelativeUserInfo[], |}, ) => ?Info, +rawMessageInfoFromMessageData?: (messageData: Data, id: string) => RawInfo, +robotext?: ( messageInfo: Info, creator: string, params: {| +encodedThreadEntity: (threadID: string, text: string) => string, +robotextForUsers: (users: RelativeUserInfo[]) => string, +robotextForUser: (user: RelativeUserInfo) => string, +threadInfo: ThreadInfo, |}, ) => string, +shimUnsupportedMessageInfo?: ( rawMessageInfo: RawInfo, platformDetails: ?PlatformDetails, ) => RawInfo | RawUnsupportedMessageInfo, +unshimMessageInfo?: ( unwrapped: RawInfo, messageInfo: RawMessageInfo, ) => ?RawMessageInfo, +notificationTexts?: ( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, params: {| +notifThreadName: (threadInfo: ThreadInfo) => string, +notifTextForSubthreadCreation: ( creator: RelativeUserInfo, threadType: ThreadType, parentThreadInfo: ThreadInfo, childThreadName: ?string, childThreadUIName: string, ) => NotifTexts, +strippedRobotextForMessageInfo: ( messageInfo: RobotextMessageInfo, threadInfo: ThreadInfo, ) => string, +notificationTexts: ( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, ) => NotifTexts, |}, ) => NotifTexts, +notificationCollapseKey?: (rawMessageInfo: RawInfo) => ?string, +generatesNotifs: boolean, +userIDs?: (rawMessageInfo: RawInfo) => $ReadOnlyArray, +startsThread?: boolean, +threadIDs?: (rawMessageInfo: RawInfo) => $ReadOnlyArray, +includedInRepliesCount?: boolean, |}; diff --git a/lib/shared/messages/text-message-spec.js b/lib/shared/messages/text-message-spec.js index ea6e30ec6..0b8fc208b 100644 --- a/lib/shared/messages/text-message-spec.js +++ b/lib/shared/messages/text-message-spec.js @@ -1,92 +1,134 @@ // @flow import invariant from 'invariant'; +import * as SimpleMarkdown from 'simple-markdown'; import { messageTypes } from '../../types/message-types'; import type { RawTextMessageInfo, TextMessageData, TextMessageInfo, } from '../../types/messages/text'; -import { firstLine } from '../../utils/string-utils'; import { threadIsGroupChat } from '../thread-utils'; import { stringForUser } from '../user-utils'; import type { MessageSpec } from './message-spec'; import { assertSingleMessageInfo } from './utils'; +/** + * most of the markdown leaves contain `content` field (it is an array or a string) + * apart from lists, which have `items` field (that holds an array) + */ +const rawTextFromMarkdownAST = (node: SimpleMarkdown.ASTNode): string => { + if (node.content && typeof node.content === 'string') { + return node.content; + } else if (node.items) { + return rawTextFromMarkdownAST(node.items); + } else if (node.content) { + return rawTextFromMarkdownAST(node.content); + } else if (Array.isArray(node)) { + return node.map(rawTextFromMarkdownAST).join(''); + } + return ''; +}; + +const getFirstNonQuotedRawLine = ( + nodes: $ReadOnlyArray, +): string => { + let result = 'message'; + for (const node of nodes) { + if (node.type === 'blockQuote') { + result = 'quoted message'; + } else { + const rawText = rawTextFromMarkdownAST(node); + if (!rawText || !rawText.replace(/\s/g, '')) { + // handles the case of an empty(or containing only white spaces) + // new line that usually occurs between a quote and the rest + // of the message(we don't want it as a title, thus continue) + continue; + } + return rawText; + } + } + return result; +}; + export const textMessageSpec: MessageSpec< TextMessageData, RawTextMessageInfo, TextMessageInfo, > = Object.freeze({ messageContent(data) { return data.text; }, - messageTitle({ messageInfo }) { - return firstLine(messageInfo.text); + messageTitle({ messageInfo, markdownRules }) { + const { text } = messageInfo; + const parser = SimpleMarkdown.parserFor(markdownRules); + const ast = parser(text, { disableAutoBlockNewlines: true }); + + return getFirstNonQuotedRawLine(ast); }, rawMessageInfoFromRow(row, params) { const rawTextMessageInfo: RawTextMessageInfo = { type: messageTypes.TEXT, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), text: row.content, }; if (params.localID) { rawTextMessageInfo.localID = params.localID; } return rawTextMessageInfo; }, createMessageInfo(rawMessageInfo, creator) { const messageInfo: TextMessageInfo = { type: messageTypes.TEXT, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, text: rawMessageInfo.text, }; if (rawMessageInfo.id) { messageInfo.id = rawMessageInfo.id; } if (rawMessageInfo.localID) { messageInfo.localID = rawMessageInfo.localID; } return messageInfo; }, rawMessageInfoFromMessageData(messageData, id) { return { ...messageData, id }; }, notificationTexts(messageInfos, threadInfo, params) { const messageInfo = assertSingleMessageInfo(messageInfos); invariant( messageInfo.type === messageTypes.TEXT, 'messageInfo should be messageTypes.TEXT!', ); if (!threadInfo.name && !threadIsGroupChat(threadInfo)) { return { merged: `${threadInfo.uiName}: ${messageInfo.text}`, body: messageInfo.text, title: threadInfo.uiName, }; } else { const userString = stringForUser(messageInfo.creator); const threadName = params.notifThreadName(threadInfo); return { merged: `${userString} to ${threadName}: ${messageInfo.text}`, body: messageInfo.text, title: threadInfo.uiName, prefix: `${userString}:`, }; } }, generatesNotifs: true, includedInRepliesCount: true, }); diff --git a/lib/shared/thread-utils.js b/lib/shared/thread-utils.js index 1bdf55f81..c767df2eb 100644 --- a/lib/shared/thread-utils.js +++ b/lib/shared/thread-utils.js @@ -1,800 +1,802 @@ // @flow import invariant from 'invariant'; import _find from 'lodash/fp/find'; import * as React from 'react'; +import { type ParserRules } from 'simple-markdown'; import tinycolor from 'tinycolor2'; import { fetchMostRecentMessagesActionTypes, fetchMostRecentMessages, } from '../actions/message-actions'; import { permissionLookup, getAllThreadPermissions, makePermissionsBlob, } from '../permissions/thread-permissions'; import type { ChatThreadItem } from '../selectors/chat-selectors'; import type { MultimediaMessageInfo, RobotextMessageInfo, } from '../types/message-types'; import type { TextMessageInfo } from '../types/messages/text'; import { userRelationshipStatus } from '../types/relationship-types'; import { type RawThreadInfo, type ThreadInfo, type ThreadPermission, type MemberInfo, type ServerThreadInfo, type RelativeMemberInfo, type ThreadCurrentUserInfo, type RoleInfo, type ServerMemberInfo, type ThreadPermissionsInfo, type ThreadType, threadTypes, threadPermissions, } from '../types/thread-types'; import { type UpdateInfo, updateTypes } from '../types/update-types'; import type { GlobalAccountUserInfo, UserInfos, UserInfo, } from '../types/user-types'; import { useDispatchActionPromise, useServerCall } from '../utils/action-utils'; import { pluralize } from '../utils/text-utils'; import { getMessageTitle } from './message-utils'; import { relationshipBlockedInEitherDirection } from './relationship-utils'; import threadWatcher from './thread-watcher'; function colorIsDark(color: string) { return tinycolor(`#${color}`).isDark(); } // Randomly distributed in RGB-space const hexNumerals = '0123456789abcdef'; function generateRandomColor() { let color = ''; for (let i = 0; i < 6; i++) { color += hexNumerals[Math.floor(Math.random() * 16)]; } return color; } function generatePendingThreadColor( userIDs: $ReadOnlyArray, viewerID: string, ) { const ids = [...userIDs, viewerID].sort().join('#'); let hash = 0; for (let i = 0; i < ids.length; i++) { hash = 1009 * hash + ids.charCodeAt(i) * 83; hash %= 1000000007; } const hashString = hash.toString(16); return hashString.substring(hashString.length - 6).padStart(6, '8'); } function threadHasPermission( threadInfo: ?(ThreadInfo | RawThreadInfo), permission: ThreadPermission, ): boolean { invariant( !permissionsDisabledByBlock.has(permission) || threadInfo?.uiName, `${permission} can be disabled by a block, but threadHasPermission can't ` + 'check for a block on RawThreadInfo. Please pass in ThreadInfo instead!', ); if (!threadInfo || !threadInfo.currentUser.permissions[permission]) { return false; } return threadInfo.currentUser.permissions[permission].value; } function viewerIsMember(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean { return !!( threadInfo && threadInfo.currentUser.role !== null && threadInfo.currentUser.role !== undefined ); } function threadIsInHome(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean { return !!(threadInfo && threadInfo.currentUser.subscription.home); } // Can have messages function threadInChatList(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean { return ( viewerIsMember(threadInfo) && threadHasPermission(threadInfo, threadPermissions.VISIBLE) ); } function threadIsTopLevel(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean { return !!( threadInChatList(threadInfo) && threadInfo && threadInfo.type !== threadTypes.SIDEBAR ); } function threadInBackgroundChatList( threadInfo: ?(ThreadInfo | RawThreadInfo), ): boolean { return threadInChatList(threadInfo) && !threadIsInHome(threadInfo); } function threadInHomeChatList( threadInfo: ?(ThreadInfo | RawThreadInfo), ): boolean { return threadInChatList(threadInfo) && threadIsInHome(threadInfo); } // Can have Calendar entries, // does appear as a top-level entity in the thread list function threadInFilterList( threadInfo: ?(ThreadInfo | RawThreadInfo), ): boolean { return ( threadInChatList(threadInfo) && !!threadInfo && threadInfo.type !== threadTypes.SIDEBAR ); } function userIsMember( threadInfo: ?(ThreadInfo | RawThreadInfo), userID: string, ): boolean { if (!threadInfo) { return false; } return threadInfo.members.some( (member) => member.id === userID && member.role !== null && member.role !== undefined, ); } function threadActualMembers( memberInfos: $ReadOnlyArray, ): $ReadOnlyArray { return memberInfos .filter( (memberInfo) => memberInfo.role !== null && memberInfo.role !== undefined, ) .map((memberInfo) => memberInfo.id); } function threadIsGroupChat(threadInfo: ThreadInfo | RawThreadInfo) { return ( threadInfo.members.filter( (member) => member.role || member.permissions[threadPermissions.VOICED]?.value, ).length > 2 ); } function threadOrParentThreadIsGroupChat( threadInfo: RawThreadInfo | ThreadInfo, ) { return threadInfo.members.length > 2; } function threadIsPending(threadID: ?string) { return threadID?.startsWith('pending'); } function threadIsPersonalAndPending(threadInfo: ?(ThreadInfo | RawThreadInfo)) { return ( threadInfo?.type === threadTypes.PERSONAL && threadIsPending(threadInfo?.id) ); } function getPendingThreadOtherUsers(threadInfo: ThreadInfo | RawThreadInfo) { invariant(threadIsPending(threadInfo.id), 'Thread should be pending'); const otherUserIDs = threadInfo.id.split('/')[1]; invariant( otherUserIDs, 'Pending thread should contain other members id in its id', ); return otherUserIDs.split('+'); } function getSingleOtherUser( threadInfo: ThreadInfo | RawThreadInfo, viewerID: ?string, ) { if (!viewerID) { return undefined; } const otherMemberIDs = threadInfo.members .map((member) => member.id) .filter((id) => id !== viewerID); if (otherMemberIDs.length !== 1) { return undefined; } return otherMemberIDs[0]; } function getPendingThreadKey(memberIDs: $ReadOnlyArray) { return [...memberIDs].sort().join('+'); } type CreatePendingThreadArgs = {| +viewerID: string, +threadType: ThreadType, +members?: $ReadOnlyArray, +parentThreadID?: ?string, +threadColor?: ?string, +name?: ?string, |}; function createPendingThread({ viewerID, threadType, members, parentThreadID, threadColor, name, }: CreatePendingThreadArgs) { const now = Date.now(); members = members ?? []; const memberIDs = members.map((member) => member.id); const threadID = `pending/${getPendingThreadKey(memberIDs)}`; const permissions = { [threadPermissions.KNOW_OF]: true, [threadPermissions.VISIBLE]: true, [threadPermissions.VOICED]: true, }; const membershipPermissions = getAllThreadPermissions( makePermissionsBlob(permissions, null, threadID, threadType), threadID, ); const role = { id: `${threadID}/role`, name: 'Members', permissions, isDefault: true, }; const rawThreadInfo = { id: threadID, type: threadType, name: name ?? null, description: null, color: threadColor ?? generatePendingThreadColor(memberIDs, viewerID), creationTime: now, parentThreadID: parentThreadID ?? null, members: [ { id: viewerID, role: role.id, permissions: membershipPermissions, isSender: false, }, ...members.map((member) => ({ id: member.id, role: role.id, permissions: membershipPermissions, isSender: false, })), ], roles: { [role.id]: role, }, currentUser: { role: role.id, permissions: membershipPermissions, subscription: { pushNotifs: false, home: false, }, unread: false, }, repliesCount: 0, }; const userInfos = {}; members.forEach((member) => (userInfos[member.id] = member)); return threadInfoFromRawThreadInfo(rawThreadInfo, viewerID, userInfos); } function createPendingThreadItem( viewerID: string, user: GlobalAccountUserInfo, ): ChatThreadItem { const threadInfo = createPendingThread({ viewerID, threadType: threadTypes.PERSONAL, members: [user], }); return { type: 'chatThreadItem', threadInfo, mostRecentMessageInfo: null, mostRecentNonLocalMessage: null, lastUpdatedTime: threadInfo.creationTime, lastUpdatedTimeIncludingSidebars: threadInfo.creationTime, sidebars: [], pendingPersonalThreadUserInfo: { id: user.id, username: user.username, }, }; } function createPendingSidebar( messageInfo: TextMessageInfo | MultimediaMessageInfo | RobotextMessageInfo, threadInfo: ThreadInfo, viewerID: string, + markdownRules: ParserRules, ) { const { id, username } = messageInfo.creator; const { id: parentThreadID, color } = threadInfo; invariant(username, 'username should be set in createPendingSidebar'); const initialMemberUserInfo: GlobalAccountUserInfo = { id, username }; return createPendingThread({ viewerID, threadType: threadTypes.SIDEBAR, members: [initialMemberUserInfo], parentThreadID, threadColor: color, - name: getMessageTitle(messageInfo, threadInfo), + name: getMessageTitle(messageInfo, threadInfo, markdownRules), }); } function pendingThreadType(numberOfOtherMembers: number) { return numberOfOtherMembers === 1 ? threadTypes.PERSONAL : threadTypes.CHAT_SECRET; } type RawThreadInfoOptions = {| +includeVisibilityRules?: ?boolean, +filterMemberList?: ?boolean, |}; function rawThreadInfoFromServerThreadInfo( serverThreadInfo: ServerThreadInfo, viewerID: string, options?: RawThreadInfoOptions, ): ?RawThreadInfo { const includeVisibilityRules = options?.includeVisibilityRules; const filterMemberList = options?.filterMemberList; const members = []; let currentUser; for (const serverMember of serverThreadInfo.members) { if ( filterMemberList && serverMember.id !== viewerID && !serverMember.role && !memberHasAdminPowers(serverMember) ) { continue; } members.push({ id: serverMember.id, role: serverMember.role, permissions: serverMember.permissions, isSender: serverMember.isSender, }); if (serverMember.id === viewerID) { currentUser = { role: serverMember.role, permissions: serverMember.permissions, subscription: serverMember.subscription, unread: serverMember.unread, }; } } let currentUserPermissions; if (currentUser) { currentUserPermissions = currentUser.permissions; } else { currentUserPermissions = getAllThreadPermissions(null, serverThreadInfo.id); currentUser = { role: null, permissions: currentUserPermissions, subscription: { home: false, pushNotifs: false, }, unread: null, }; } if (!permissionLookup(currentUserPermissions, threadPermissions.KNOW_OF)) { return null; } const rawThreadInfo: RawThreadInfo = { id: serverThreadInfo.id, type: serverThreadInfo.type, name: serverThreadInfo.name, description: serverThreadInfo.description, color: serverThreadInfo.color, creationTime: serverThreadInfo.creationTime, parentThreadID: serverThreadInfo.parentThreadID, members, roles: serverThreadInfo.roles, currentUser, repliesCount: serverThreadInfo.repliesCount, }; const sourceMessageID = serverThreadInfo.sourceMessageID; if (sourceMessageID) { rawThreadInfo.sourceMessageID = sourceMessageID; } if (!includeVisibilityRules) { return rawThreadInfo; } return ({ ...rawThreadInfo, visibilityRules: rawThreadInfo.type, }: any); } function robotextName( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): string { const threadUsernames: string[] = threadInfo.members .filter( (threadMember) => threadMember.id !== viewerID && (threadMember.role || memberHasAdminPowers(threadMember)), ) .map( (threadMember) => userInfos[threadMember.id] && userInfos[threadMember.id].username, ) .filter(Boolean); if (threadUsernames.length === 0) { return 'just you'; } return pluralize(threadUsernames); } function threadUIName( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): string { if (threadInfo.name) { return threadInfo.name; } return robotextName(threadInfo, viewerID, userInfos); } function threadInfoFromRawThreadInfo( rawThreadInfo: RawThreadInfo, viewerID: ?string, userInfos: UserInfos, ): ThreadInfo { const threadInfo: ThreadInfo = { id: rawThreadInfo.id, type: rawThreadInfo.type, name: rawThreadInfo.name, uiName: threadUIName(rawThreadInfo, viewerID, userInfos), description: rawThreadInfo.description, color: rawThreadInfo.color, creationTime: rawThreadInfo.creationTime, parentThreadID: rawThreadInfo.parentThreadID, members: rawThreadInfo.members, roles: rawThreadInfo.roles, currentUser: getCurrentUser(rawThreadInfo, viewerID, userInfos), repliesCount: rawThreadInfo.repliesCount, }; const { sourceMessageID } = rawThreadInfo; if (sourceMessageID) { threadInfo.sourceMessageID = sourceMessageID; } return threadInfo; } function getCurrentUser( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): ThreadCurrentUserInfo { if (!threadFrozenDueToBlock(threadInfo, viewerID, userInfos)) { return threadInfo.currentUser; } return { ...threadInfo.currentUser, permissions: { ...threadInfo.currentUser.permissions, ...disabledPermissions, }, }; } function threadIsWithBlockedUserOnly( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, checkOnlyViewerBlock?: boolean, ): boolean { if ( threadOrParentThreadIsGroupChat(threadInfo) || threadOrParentThreadHasAdminRole(threadInfo) ) { return false; } const otherUserID = getSingleOtherUser(threadInfo, viewerID); if (!otherUserID) { return false; } const otherUserRelationshipStatus = userInfos[otherUserID]?.relationshipStatus; if (checkOnlyViewerBlock) { return ( otherUserRelationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER ); } return ( !!otherUserRelationshipStatus && relationshipBlockedInEitherDirection(otherUserRelationshipStatus) ); } function threadFrozenDueToBlock( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): boolean { return threadIsWithBlockedUserOnly(threadInfo, viewerID, userInfos); } function threadFrozenDueToViewerBlock( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): boolean { return threadIsWithBlockedUserOnly(threadInfo, viewerID, userInfos, true); } function rawThreadInfoFromThreadInfo(threadInfo: ThreadInfo): RawThreadInfo { const rawThreadInfo: RawThreadInfo = { id: threadInfo.id, type: threadInfo.type, name: threadInfo.name, description: threadInfo.description, color: threadInfo.color, creationTime: threadInfo.creationTime, parentThreadID: threadInfo.parentThreadID, members: threadInfo.members, roles: threadInfo.roles, currentUser: threadInfo.currentUser, repliesCount: threadInfo.repliesCount, }; const { sourceMessageID } = threadInfo; if (sourceMessageID) { rawThreadInfo.sourceMessageID = sourceMessageID; } return rawThreadInfo; } const threadTypeDescriptions = { [threadTypes.CHAT_NESTED_OPEN]: 'Anybody in the parent thread can see an open child thread.', [threadTypes.CHAT_SECRET]: 'Only visible to its members and admins of ancestor threads.', }; function usersInThreadInfo(threadInfo: RawThreadInfo | ThreadInfo): string[] { const userIDs = new Set(); for (let member of threadInfo.members) { userIDs.add(member.id); } return [...userIDs]; } function memberIsAdmin( memberInfo: RelativeMemberInfo | MemberInfo, threadInfo: ThreadInfo | RawThreadInfo, ) { return memberInfo.role && roleIsAdminRole(threadInfo.roles[memberInfo.role]); } // Since we don't have access to all of the ancestor ThreadInfos, we approximate // "parent admin" as anybody with CHANGE_ROLE permissions. function memberHasAdminPowers( memberInfo: RelativeMemberInfo | MemberInfo | ServerMemberInfo, ): boolean { return !!memberInfo.permissions[threadPermissions.CHANGE_ROLE]?.value; } function roleIsAdminRole(roleInfo: ?RoleInfo) { return roleInfo && !roleInfo.isDefault && roleInfo.name === 'Admins'; } function threadHasAdminRole( threadInfo: ?(RawThreadInfo | ThreadInfo | ServerThreadInfo), ) { if (!threadInfo) { return false; } return _find({ name: 'Admins' })(threadInfo.roles); } function threadOrParentThreadHasAdminRole( threadInfo: RawThreadInfo | ThreadInfo, ) { return ( threadInfo.members.filter((member) => memberHasAdminPowers(member)).length > 0 ); } function identifyInvalidatedThreads( updateInfos: $ReadOnlyArray, ): Set { const invalidated = new Set(); for (const updateInfo of updateInfos) { if (updateInfo.type === updateTypes.DELETE_THREAD) { invalidated.add(updateInfo.threadID); } } return invalidated; } const permissionsDisabledByBlockArray = [ threadPermissions.VOICED, threadPermissions.EDIT_ENTRIES, threadPermissions.EDIT_THREAD, threadPermissions.CREATE_SUBTHREADS, threadPermissions.CREATE_SIDEBARS, threadPermissions.JOIN_THREAD, threadPermissions.EDIT_PERMISSIONS, threadPermissions.ADD_MEMBERS, threadPermissions.REMOVE_MEMBERS, ]; const permissionsDisabledByBlock: Set = new Set( permissionsDisabledByBlockArray, ); const disabledPermissions: ThreadPermissionsInfo = permissionsDisabledByBlockArray.reduce( (permissions: ThreadPermissionsInfo, permission: string) => ({ ...permissions, [permission]: { value: false, source: null }, }), {}, ); // Consider updating itemHeight in native/chat/chat-thread-list.react.js // if you change this const emptyItemText = `Background threads are just like normal threads, except they don't ` + `contribute to your unread count.\n\n` + `To move a thread over here, switch the “Background” option in its settings.`; const threadSearchText = ( threadInfo: RawThreadInfo | ThreadInfo, userInfos: UserInfos, ): string => { const searchTextArray = []; if (threadInfo.name) { searchTextArray.push(threadInfo.name); } if (threadInfo.description) { searchTextArray.push(threadInfo.description); } for (let member of threadInfo.members) { const isParentAdmin = memberHasAdminPowers(member); if (!member.role && !isParentAdmin) { continue; } const userInfo = userInfos[member.id]; if (userInfo && userInfo.username) { searchTextArray.push(userInfo.username); } } return searchTextArray.join(' '); }; function threadNoun(threadType: ThreadType) { return threadType === threadTypes.SIDEBAR ? 'sidebar' : 'thread'; } function threadLabel(threadType: ThreadType) { if (threadType === threadTypes.CHAT_SECRET) { return 'Secret'; } else if (threadType === threadTypes.PERSONAL) { return 'Personal'; } else if (threadType === threadTypes.SIDEBAR) { return 'Sidebar'; } else if (threadType === threadTypes.PRIVATE) { return 'Private'; } else if (threadType === threadTypes.CHAT_NESTED_OPEN) { return 'Open'; } invariant(false, `unexpected threadType ${threadType}`); } function useWatchThread(threadInfo: ?ThreadInfo) { const dispatchActionPromise = useDispatchActionPromise(); const callFetchMostRecentMessages = useServerCall(fetchMostRecentMessages); const threadID = threadInfo?.id; const threadNotInChatList = !threadInChatList(threadInfo); React.useEffect(() => { if (threadID && threadNotInChatList) { threadWatcher.watchID(threadID); dispatchActionPromise( fetchMostRecentMessagesActionTypes, callFetchMostRecentMessages(threadID), ); } return () => { if (threadID && threadNotInChatList) { threadWatcher.removeID(threadID); } }; }, [ callFetchMostRecentMessages, dispatchActionPromise, threadNotInChatList, threadID, ]); } export { colorIsDark, generateRandomColor, generatePendingThreadColor, threadHasPermission, viewerIsMember, threadInChatList, threadIsTopLevel, threadInBackgroundChatList, threadInHomeChatList, threadIsInHome, threadInFilterList, userIsMember, threadActualMembers, threadIsGroupChat, threadIsPending, threadIsPersonalAndPending, getPendingThreadOtherUsers, getSingleOtherUser, getPendingThreadKey, createPendingThread, createPendingThreadItem, createPendingSidebar, pendingThreadType, getCurrentUser, threadFrozenDueToBlock, threadFrozenDueToViewerBlock, rawThreadInfoFromServerThreadInfo, robotextName, threadInfoFromRawThreadInfo, rawThreadInfoFromThreadInfo, threadTypeDescriptions, usersInThreadInfo, memberIsAdmin, memberHasAdminPowers, roleIsAdminRole, threadHasAdminRole, identifyInvalidatedThreads, permissionsDisabledByBlock, emptyItemText, threadSearchText, threadNoun, threadLabel, useWatchThread, }; diff --git a/lib/utils/string-utils.js b/lib/utils/string-utils.js index f7fd45229..406166729 100644 --- a/lib/utils/string-utils.js +++ b/lib/utils/string-utils.js @@ -1,11 +1,11 @@ // @flow const newlineRegex = /[\r\n]/; function firstLine(text: ?string): string { if (!text) { return ''; } return text.split(newlineRegex, 1)[0]; } -export { firstLine }; +export { newlineRegex, firstLine }; diff --git a/native/chat/message-preview.react.js b/native/chat/message-preview.react.js index ad2b4334e..737b3a5c5 100644 --- a/native/chat/message-preview.react.js +++ b/native/chat/message-preview.react.js @@ -1,101 +1,109 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text } from 'react-native'; -import { messagePreviewText } from 'lib/shared/message-utils'; +import { getMessageTitle } from 'lib/shared/message-utils'; import { threadIsGroupChat } from 'lib/shared/thread-utils'; import { stringForUser } from 'lib/shared/user-utils'; -import { type MessageInfo, messageTypes } from 'lib/types/message-types'; +import { + type MessageInfo, + messageTypes, + type ComposableMessageInfo, + type RobotextMessageInfo, +} from 'lib/types/message-types'; import { type ThreadInfo } from 'lib/types/thread-types'; import { connect } from 'lib/utils/redux-utils'; -import { firstLine } from 'lib/utils/string-utils'; import { SingleLine } from '../components/single-line.react'; +import { getDefaultTextMessageRules } from '../markdown/rules.react'; import type { AppState } from '../redux/redux-setup'; import { styleSelector } from '../themes/colors'; type Props = {| - messageInfo: MessageInfo, - threadInfo: ThreadInfo, + +messageInfo: MessageInfo, + +threadInfo: ThreadInfo, // Redux state - styles: typeof styles, + +styles: typeof styles, |}; class MessagePreview extends React.PureComponent { render() { - const messageInfo: MessageInfo = + const messageInfo: ComposableMessageInfo | RobotextMessageInfo = this.props.messageInfo.type === messageTypes.SIDEBAR_SOURCE ? this.props.messageInfo.sourceMessage : this.props.messageInfo; const unreadStyle = this.props.threadInfo.currentUser.unread ? this.props.styles.unread : null; + const messageTitle = getMessageTitle( + messageInfo, + this.props.threadInfo, + getDefaultTextMessageRules().simpleMarkdownRules, + ); if (messageInfo.type === messageTypes.TEXT) { let usernameText = null; if ( threadIsGroupChat(this.props.threadInfo) || this.props.threadInfo.name !== '' || messageInfo.creator.isViewer ) { const userString = stringForUser(messageInfo.creator); const username = `${userString}: `; usernameText = ( {username} ); } - const firstMessageLine = firstLine(messageInfo.text); return ( {usernameText} - {firstMessageLine} + {messageTitle} ); } else { invariant( messageInfo.type !== messageTypes.SIDEBAR_SOURCE, 'Sidebar source should not be handled here', ); - const preview = messagePreviewText(messageInfo, this.props.threadInfo); return ( - {preview} + {messageTitle} ); } } } const styles = { lastMessage: { color: 'listForegroundTertiaryLabel', flex: 1, fontSize: 16, paddingLeft: 10, }, preview: { color: 'listForegroundQuaternaryLabel', }, unread: { color: 'listForegroundLabel', }, username: { color: 'listForegroundQuaternaryLabel', }, }; const stylesSelector = styleSelector(styles); export default connect((state: AppState) => ({ styles: stylesSelector(state), }))(MessagePreview); diff --git a/native/chat/sidebar-navigation.js b/native/chat/sidebar-navigation.js index 7e3216508..049b55cb9 100644 --- a/native/chat/sidebar-navigation.js +++ b/native/chat/sidebar-navigation.js @@ -1,96 +1,98 @@ // @flow import invariant from 'invariant'; import { createPendingSidebar } from 'lib/shared/thread-utils'; import type { DispatchFunctions, ActionFunc, BoundServerCall, } from 'lib/utils/action-utils'; import type { InputState } from '../input/input-state'; +import { getDefaultTextMessageRules } from '../markdown/rules.react'; import type { AppNavigationProp } from '../navigation/app-navigator.react'; import { MessageListRouteName } from '../navigation/route-names'; import type { TooltipRoute } from '../navigation/tooltip.react'; function onPressGoToSidebar( route: | TooltipRoute<'RobotextMessageTooltipModal'> | TooltipRoute<'TextMessageTooltipModal'> | TooltipRoute<'MultimediaTooltipModal'>, dispatchFunctions: DispatchFunctions, bindServerCall: (serverCall: ActionFunc) => BoundServerCall, inputState: ?InputState, navigation: | AppNavigationProp<'RobotextMessageTooltipModal'> | AppNavigationProp<'TextMessageTooltipModal'> | AppNavigationProp<'MultimediaTooltipModal'>, ) { let threadCreatedFromMessage; // Necessary for Flow... if (route.name === 'RobotextMessageTooltipModal') { threadCreatedFromMessage = route.params.item.threadCreatedFromMessage; } else { threadCreatedFromMessage = route.params.item.threadCreatedFromMessage; } invariant( threadCreatedFromMessage, 'threadCreatedFromMessage should be set in onPressGoToSidebar', ); navigation.navigate({ name: MessageListRouteName, params: { threadInfo: threadCreatedFromMessage, }, key: `${MessageListRouteName}${threadCreatedFromMessage.id}`, }); } function onPressCreateSidebar( route: | TooltipRoute<'RobotextMessageTooltipModal'> | TooltipRoute<'TextMessageTooltipModal'> | TooltipRoute<'MultimediaTooltipModal'>, dispatchFunctions: DispatchFunctions, bindServerCall: (serverCall: ActionFunc) => BoundServerCall, inputState: ?InputState, navigation: | AppNavigationProp<'RobotextMessageTooltipModal'> | AppNavigationProp<'TextMessageTooltipModal'> | AppNavigationProp<'MultimediaTooltipModal'>, viewerID: ?string, ) { invariant( viewerID, 'viewerID should be set in TextMessageTooltipModal.onPressCreateSidebar', ); let itemFromParams; // Necessary for Flow... if (route.name === 'RobotextMessageTooltipModal') { itemFromParams = route.params.item; } else { itemFromParams = route.params.item; } const { messageInfo, threadInfo } = itemFromParams; const pendingSidebarInfo = createPendingSidebar( messageInfo, threadInfo, viewerID, + getDefaultTextMessageRules().simpleMarkdownRules, ); const sourceMessageID = messageInfo.id; navigation.navigate({ name: MessageListRouteName, params: { threadInfo: pendingSidebarInfo, sidebarSourceMessageID: sourceMessageID, }, key: `${MessageListRouteName}${pendingSidebarInfo.id}`, }); } export { onPressGoToSidebar, onPressCreateSidebar }; diff --git a/native/markdown/rules.react.js b/native/markdown/rules.react.js index 87f3e6c11..c66054896 100644 --- a/native/markdown/rules.react.js +++ b/native/markdown/rules.react.js @@ -1,374 +1,387 @@ // @flow import _memoize from 'lodash/memoize'; import * as React from 'react'; import { Text, View } from 'react-native'; import * as SimpleMarkdown from 'simple-markdown'; import { relativeMemberInfoSelectorForMembersOfThread } from 'lib/selectors/user-selectors'; import * as SharedMarkdown from 'lib/shared/markdown'; import type { RelativeMemberInfo } from 'lib/types/thread-types'; import { useSelector } from '../redux/redux-utils'; import MarkdownLink from './markdown-link.react'; import { getMarkdownStyles } from './styles'; export type MarkdownRules = {| +simpleMarkdownRules: SimpleMarkdown.ParserRules, +emojiOnlyFactor: ?number, // We need to use a Text container for Entry because it needs to match up // exactly with TextInput. However, if we use a Text container, we can't // support styles for things like blockQuote, which rely on rendering as a // View, and Views can't be nested inside Texts without explicit height and // width +container: 'View' | 'Text', |}; // Entry requires a seamless transition between Markdown and TextInput // components, so we can't do anything that would change the position of text const inlineMarkdownRules: (boolean) => MarkdownRules = _memoize( (useDarkStyle) => { const styles = getMarkdownStyles(useDarkStyle ? 'dark' : 'light'); const simpleMarkdownRules = { // Matches 'https://google.com' during parse phase and returns a 'link' node url: { ...SimpleMarkdown.defaultRules.url, // simple-markdown is case-sensitive, but we don't want to be match: SimpleMarkdown.inlineRegex(SharedMarkdown.urlRegex), }, // Matches '[Google](https://google.com)' during parse phase and handles // rendering all 'link' nodes, including for 'autolink' and 'url' link: { ...SimpleMarkdown.defaultRules.link, match: () => null, react( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) { return ( {output(node.content, state)} ); }, }, // Each line gets parsed into a 'paragraph' node. The AST returned by the // parser will be an array of one or more 'paragraph' nodes paragraph: { ...SimpleMarkdown.defaultRules.paragraph, // simple-markdown's default RegEx collapses multiple newlines into one. // We want to keep the newlines, but when rendering within a View, we // strip just one trailing newline off, since the View adds vertical // spacing between its children match: (source: string, state: SimpleMarkdown.State) => { if (state.inline) { return null; } else if (state.container === 'View') { return SharedMarkdown.paragraphStripTrailingNewlineRegex.exec( source, ); } else { return SharedMarkdown.paragraphRegex.exec(source); } }, parse( capture: SimpleMarkdown.Capture, parse: SimpleMarkdown.Parser, state: SimpleMarkdown.State, ) { let content = capture[1]; if (state.container === 'View') { // React Native renders empty lines with less height. We want to // preserve the newline characters, so we replace empty lines with a // single space content = content.replace(/^$/m, ' '); } return { content: SimpleMarkdown.parseInline(parse, content, state), }; }, // eslint-disable-next-line react/display-name react: ( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) => ( {output(node.content, state)} ), }, // This is the leaf node in the AST returned by the parse phase text: SimpleMarkdown.defaultRules.text, }; return { simpleMarkdownRules, emojiOnlyFactor: null, container: 'Text', }; }, ); // We allow the most markdown features for TextMessage, which doesn't have the // same requirements as Entry const fullMarkdownRules: (boolean) => MarkdownRules = _memoize( (useDarkStyle) => { const styles = getMarkdownStyles(useDarkStyle ? 'dark' : 'light'); const inlineRules = inlineMarkdownRules(useDarkStyle); const simpleMarkdownRules = { ...inlineRules.simpleMarkdownRules, // Matches '' during parse phase and returns a 'link' // node autolink: SimpleMarkdown.defaultRules.autolink, // Matches '[Google](https://google.com)' during parse phase and handles // rendering all 'link' nodes, including for 'autolink' and 'url' link: { ...inlineRules.simpleMarkdownRules.link, match: SimpleMarkdown.defaultRules.link.match, }, mailto: SimpleMarkdown.defaultRules.mailto, em: { ...SimpleMarkdown.defaultRules.em, // eslint-disable-next-line react/display-name react: ( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) => ( {output(node.content, state)} ), }, strong: { ...SimpleMarkdown.defaultRules.strong, // eslint-disable-next-line react/display-name react: ( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) => ( {output(node.content, state)} ), }, u: { ...SimpleMarkdown.defaultRules.u, // eslint-disable-next-line react/display-name react: ( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) => ( {output(node.content, state)} ), }, del: { ...SimpleMarkdown.defaultRules.del, // eslint-disable-next-line react/display-name react: ( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) => ( {output(node.content, state)} ), }, inlineCode: { ...SimpleMarkdown.defaultRules.inlineCode, // eslint-disable-next-line react/display-name react: ( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) => ( {node.content} ), }, heading: { ...SimpleMarkdown.defaultRules.heading, match: SimpleMarkdown.blockRegex( SharedMarkdown.headingStripFollowingNewlineRegex, ), // eslint-disable-next-line react/display-name react( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) { const headingStyle = styles['h' + node.level]; return ( {output(node.content, state)} ); }, }, blockQuote: { ...SimpleMarkdown.defaultRules.blockQuote, // match end of blockQuote by either \n\n or end of string match: SimpleMarkdown.blockRegex( SharedMarkdown.blockQuoteStripFollowingNewlineRegex, ), parse( capture: SimpleMarkdown.Capture, parse: SimpleMarkdown.Parser, state: SimpleMarkdown.State, ) { const content = capture[1].replace(/^ *> ?/gm, ''); return { content: parse(content, state), }; }, // eslint-disable-next-line react/display-name react: ( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) => ( {output(node.content, state)} ), }, codeBlock: { ...SimpleMarkdown.defaultRules.codeBlock, match: SimpleMarkdown.blockRegex( SharedMarkdown.codeBlockStripTrailingNewlineRegex, ), parse(capture: SimpleMarkdown.Capture) { return { content: capture[1].replace(/^ {4}/gm, ''), }; }, // eslint-disable-next-line react/display-name react: ( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) => ( {node.content} ), }, fence: { ...SimpleMarkdown.defaultRules.fence, match: SimpleMarkdown.blockRegex( SharedMarkdown.fenceStripTrailingNewlineRegex, ), parse: (capture: SimpleMarkdown.Capture) => ({ type: 'codeBlock', content: capture[2], }), }, json: { order: SimpleMarkdown.defaultRules.paragraph.order - 1, match: (source: string, state: SimpleMarkdown.State) => { if (state.inline) { return null; } return SharedMarkdown.jsonMatch(source); }, parse: (capture: SimpleMarkdown.Capture) => ({ type: 'codeBlock', content: SharedMarkdown.jsonPrint(capture), }), }, list: { ...SimpleMarkdown.defaultRules.list, match: SharedMarkdown.matchList, parse: SharedMarkdown.parseList, react( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) { const children = node.items.map((item, i) => { const content = output(item, state); const bulletValue = node.ordered ? node.start + i + '. ' : '\u2022 '; return ( {bulletValue} {content} ); }); return {children}; }, }, escape: SimpleMarkdown.defaultRules.escape, }; return { ...inlineRules, simpleMarkdownRules, emojiOnlyFactor: 2, container: 'View', }; }, ); function useTextMessageRulesFunc(threadID: string) { const threadMembers = useSelector( relativeMemberInfoSelectorForMembersOfThread(threadID), ); return React.useMemo( () => _memoize<[boolean], MarkdownRules>((useDarkStyle: boolean) => textMessageRules(threadMembers, useDarkStyle), ), [threadMembers], ); } function textMessageRules( members: $ReadOnlyArray, useDarkStyle: boolean, -) { +): MarkdownRules { const styles = getMarkdownStyles(useDarkStyle ? 'dark' : 'light'); const baseRules = fullMarkdownRules(useDarkStyle); return { ...baseRules, simpleMarkdownRules: { ...baseRules.simpleMarkdownRules, mention: { ...SimpleMarkdown.defaultRules.strong, match: SharedMarkdown.matchMentions(members), parse: (capture: SimpleMarkdown.Capture) => ({ content: capture[0], }), // eslint-disable-next-line react/display-name react: ( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) => ( {node.content} ), }, }, }; } -export { inlineMarkdownRules, useTextMessageRulesFunc }; +let defaultTextMessageRules = null; + +function getDefaultTextMessageRules(): MarkdownRules { + if (!defaultTextMessageRules) { + defaultTextMessageRules = textMessageRules([], false); + } + return defaultTextMessageRules; +} + +export { + inlineMarkdownRules, + useTextMessageRulesFunc, + getDefaultTextMessageRules, +}; diff --git a/web/chat/message-preview.react.js b/web/chat/message-preview.react.js index 10e0a5818..f4c44c383 100644 --- a/web/chat/message-preview.react.js +++ b/web/chat/message-preview.react.js @@ -1,65 +1,73 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; -import { messagePreviewText } from 'lib/shared/message-utils'; +import { getMessageTitle } from 'lib/shared/message-utils'; import { threadIsGroupChat } from 'lib/shared/thread-utils'; import { stringForUser } from 'lib/shared/user-utils'; -import { type MessageInfo, messageTypes } from 'lib/types/message-types'; +import { + type MessageInfo, + messageTypes, + type ComposableMessageInfo, + type RobotextMessageInfo, +} from 'lib/types/message-types'; import { type ThreadInfo } from 'lib/types/thread-types'; -import { firstLine } from 'lib/utils/string-utils'; +import { getDefaultTextMessageRules } from '../markdown/rules.react'; import css from './chat-thread-list.css'; type Props = {| - messageInfo: ?MessageInfo, - threadInfo: ThreadInfo, + +messageInfo: ?MessageInfo, + +threadInfo: ThreadInfo, |}; class MessagePreview extends React.PureComponent { render() { - const messageInfo = - this.props.messageInfo && - (this.props.messageInfo.type === messageTypes.SIDEBAR_SOURCE - ? this.props.messageInfo.sourceMessage - : this.props.messageInfo); - if (!messageInfo) { + if (!this.props.messageInfo) { return (
No messages
); } + const messageInfo: ComposableMessageInfo | RobotextMessageInfo = + this.props.messageInfo.type === messageTypes.SIDEBAR_SOURCE + ? this.props.messageInfo.sourceMessage + : this.props.messageInfo; const unread = this.props.threadInfo.currentUser.unread; + const messageTitle = getMessageTitle( + messageInfo, + this.props.threadInfo, + getDefaultTextMessageRules().simpleMarkdownRules, + ); if (messageInfo.type === messageTypes.TEXT) { let usernameText = null; if ( threadIsGroupChat(this.props.threadInfo) || this.props.threadInfo.name !== '' || messageInfo.creator.isViewer ) { const userString = stringForUser(messageInfo.creator); const username = `${userString}: `; const usernameStyle = unread ? css.black : css.light; usernameText = {username}; } const colorStyle = unread ? css.black : css.dark; return (
{usernameText} - {firstLine(messageInfo.text)} + {messageTitle}
); } else { - const preview = messagePreviewText(messageInfo, this.props.threadInfo); const colorStyle = unread ? css.black : css.light; return (
- {firstLine(preview)} + {messageTitle}
); } } } export default MessagePreview; diff --git a/web/markdown/rules.react.js b/web/markdown/rules.react.js index 4066c093c..88e203a45 100644 --- a/web/markdown/rules.react.js +++ b/web/markdown/rules.react.js @@ -1,185 +1,194 @@ // @flow import _memoize from 'lodash/memoize'; import * as React from 'react'; import * as SimpleMarkdown from 'simple-markdown'; import { relativeMemberInfoSelectorForMembersOfThread } from 'lib/selectors/user-selectors'; import * as SharedMarkdown from 'lib/shared/markdown'; import type { RelativeMemberInfo } from 'lib/types/thread-types'; import { useSelector } from '../redux/redux-utils'; export type MarkdownRules = {| +simpleMarkdownRules: SimpleMarkdown.Rules, +useDarkStyle: boolean, |}; const linkRules: (boolean) => MarkdownRules = _memoize((useDarkStyle) => { const simpleMarkdownRules = { // We are using default simple-markdown rules // For more details, look at native/markdown/rules.react link: { ...SimpleMarkdown.defaultRules.link, match: () => null, // eslint-disable-next-line react/display-name react: ( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) => ( {output(node.content, state)} ), }, paragraph: { ...SimpleMarkdown.defaultRules.paragraph, match: SimpleMarkdown.blockRegex(SharedMarkdown.paragraphRegex), // eslint-disable-next-line react/display-name react: ( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) => ( {output(node.content, state)} ), }, text: SimpleMarkdown.defaultRules.text, url: { ...SimpleMarkdown.defaultRules.url, match: SimpleMarkdown.inlineRegex(SharedMarkdown.urlRegex), }, }; return { simpleMarkdownRules: simpleMarkdownRules, useDarkStyle, }; }); const markdownRules: (boolean) => MarkdownRules = _memoize((useDarkStyle) => { const linkMarkdownRules = linkRules(useDarkStyle); const simpleMarkdownRules = { ...linkMarkdownRules.simpleMarkdownRules, autolink: SimpleMarkdown.defaultRules.autolink, link: { ...linkMarkdownRules.simpleMarkdownRules.link, match: SimpleMarkdown.defaultRules.link.match, }, blockQuote: { ...SimpleMarkdown.defaultRules.blockQuote, // match end of blockQuote by either \n\n or end of string match: SimpleMarkdown.blockRegex(SharedMarkdown.blockQuoteRegex), parse( capture: SimpleMarkdown.Capture, parse: SimpleMarkdown.Parser, state: SimpleMarkdown.State, ) { const content = capture[1].replace(/^ *> ?/gm, ''); return { content: parse(content, state), }; }, }, inlineCode: SimpleMarkdown.defaultRules.inlineCode, em: SimpleMarkdown.defaultRules.em, strong: SimpleMarkdown.defaultRules.strong, del: SimpleMarkdown.defaultRules.del, u: SimpleMarkdown.defaultRules.u, heading: { ...SimpleMarkdown.defaultRules.heading, match: SimpleMarkdown.blockRegex(SharedMarkdown.headingRegex), }, mailto: SimpleMarkdown.defaultRules.mailto, codeBlock: { ...SimpleMarkdown.defaultRules.codeBlock, match: SimpleMarkdown.blockRegex(SharedMarkdown.codeBlockRegex), parse: (capture: SimpleMarkdown.Capture) => ({ content: capture[0].replace(/^ {4}/gm, ''), }), }, fence: { ...SimpleMarkdown.defaultRules.fence, match: SimpleMarkdown.blockRegex(SharedMarkdown.fenceRegex), parse: (capture: SimpleMarkdown.Capture) => ({ type: 'codeBlock', content: capture[2], }), }, json: { order: SimpleMarkdown.defaultRules.paragraph.order - 1, match: (source: string, state: SimpleMarkdown.State) => { if (state.inline) { return null; } return SharedMarkdown.jsonMatch(source); }, parse: (capture: SimpleMarkdown.Capture) => ({ type: 'codeBlock', content: SharedMarkdown.jsonPrint(capture), }), }, list: { ...SimpleMarkdown.defaultRules.list, match: SharedMarkdown.matchList, parse: SharedMarkdown.parseList, }, escape: SimpleMarkdown.defaultRules.escape, }; return { ...linkMarkdownRules, simpleMarkdownRules, useDarkStyle, }; }); function useTextMessageRulesFunc(threadID: ?string) { const threadMembers = useSelector( relativeMemberInfoSelectorForMembersOfThread(threadID), ); return React.useMemo(() => { if (!threadMembers) { return undefined; } return _memoize<[boolean], MarkdownRules>((useDarkStyle: boolean) => textMessageRules(threadMembers, useDarkStyle), ); }, [threadMembers]); } function textMessageRules( members: $ReadOnlyArray, useDarkStyle: boolean, -) { +): MarkdownRules { const baseRules = markdownRules(useDarkStyle); return { ...baseRules, simpleMarkdownRules: { ...baseRules.simpleMarkdownRules, mention: { ...SimpleMarkdown.defaultRules.strong, match: SharedMarkdown.matchMentions(members), parse: (capture: SimpleMarkdown.Capture) => ({ content: capture[0], }), // eslint-disable-next-line react/display-name react: ( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) => {node.content}, }, }, }; } -export { linkRules, useTextMessageRulesFunc }; +let defaultTextMessageRules = null; + +function getDefaultTextMessageRules(): MarkdownRules { + if (!defaultTextMessageRules) { + defaultTextMessageRules = textMessageRules([], false); + } + return defaultTextMessageRules; +} + +export { linkRules, useTextMessageRulesFunc, getDefaultTextMessageRules }; diff --git a/web/selectors/nav-selectors.js b/web/selectors/nav-selectors.js index b261e34c8..a7fd2d2e3 100644 --- a/web/selectors/nav-selectors.js +++ b/web/selectors/nav-selectors.js @@ -1,187 +1,189 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { useDispatch } from 'react-redux'; import { createSelector } from 'reselect'; import { nonThreadCalendarFiltersSelector } from 'lib/selectors/calendar-filter-selectors'; import { currentCalendarQuery } from 'lib/selectors/nav-selectors'; import { createPendingSidebar } from 'lib/shared/thread-utils'; import type { CalendarQuery } from 'lib/types/entry-types'; import type { CalendarFilter } from 'lib/types/filter-types'; import type { ComposableMessageInfo, RobotextMessageInfo, } from 'lib/types/message-types'; import type { ThreadInfo } from 'lib/types/thread-types'; +import { getDefaultTextMessageRules } from '../markdown/rules.react'; import type { AppState } from '../redux/redux-setup'; import { updateNavInfoActionType } from '../redux/redux-setup'; import { useSelector } from '../redux/redux-utils'; const dateExtractionRegex = /^([0-9]{4})-([0-9]{2})-[0-9]{2}$/; function yearExtractor(startDate: string, endDate: string): ?number { const startDateResults = dateExtractionRegex.exec(startDate); const endDateResults = dateExtractionRegex.exec(endDate); if ( !startDateResults || !startDateResults[1] || !endDateResults || !endDateResults[1] || startDateResults[1] !== endDateResults[1] ) { return null; } return parseInt(startDateResults[1], 10); } function yearAssertingExtractor(startDate: string, endDate: string): number { const result = yearExtractor(startDate, endDate); invariant( result !== null && result !== undefined, `${startDate} and ${endDate} aren't in the same year`, ); return result; } const yearAssertingSelector: (state: AppState) => number = createSelector( (state: AppState) => state.navInfo.startDate, (state: AppState) => state.navInfo.endDate, yearAssertingExtractor, ); // 1-indexed function monthExtractor(startDate: string, endDate: string): ?number { const startDateResults = dateExtractionRegex.exec(startDate); const endDateResults = dateExtractionRegex.exec(endDate); if ( !startDateResults || !startDateResults[1] || !startDateResults[2] || !endDateResults || !endDateResults[1] || !endDateResults[2] || startDateResults[1] !== endDateResults[1] || startDateResults[2] !== endDateResults[2] ) { return null; } return parseInt(startDateResults[2], 10); } // 1-indexed function monthAssertingExtractor(startDate: string, endDate: string): number { const result = monthExtractor(startDate, endDate); invariant( result !== null && result !== undefined, `${startDate} and ${endDate} aren't in the same month`, ); return result; } // 1-indexed const monthAssertingSelector: (state: AppState) => number = createSelector( (state: AppState) => state.navInfo.startDate, (state: AppState) => state.navInfo.endDate, monthAssertingExtractor, ); function activeThreadSelector(state: AppState): ?string { return state.navInfo.tab === 'chat' ? state.navInfo.activeChatThreadID : null; } const webCalendarQuery: ( state: AppState, ) => () => CalendarQuery = createSelector( currentCalendarQuery, (state: AppState) => state.navInfo.tab === 'calendar', ( calendarQuery: (calendarActive: boolean) => CalendarQuery, calendarActive: boolean, ) => () => calendarQuery(calendarActive), ); const nonThreadCalendarQuery: ( state: AppState, ) => () => CalendarQuery = createSelector( webCalendarQuery, nonThreadCalendarFiltersSelector, ( calendarQuery: () => CalendarQuery, filters: $ReadOnlyArray, ) => { return (): CalendarQuery => { const query = calendarQuery(); return { startDate: query.startDate, endDate: query.endDate, filters, }; }; }, ); function useOnClickThread(threadID: string) { const dispatch = useDispatch(); return React.useCallback( (event: SyntheticEvent) => { event.preventDefault(); dispatch({ type: updateNavInfoActionType, payload: { activeChatThreadID: threadID, }, }); }, [dispatch, threadID], ); } function useThreadIsActive(threadID: string) { return useSelector((state) => threadID === state.navInfo.activeChatThreadID); } function useOnClickPendingSidebar( messageInfo: ComposableMessageInfo | RobotextMessageInfo, threadInfo: ThreadInfo, ) { const dispatch = useDispatch(); const viewerID = useSelector((state) => state.currentUserInfo?.id); return React.useCallback( (event: SyntheticEvent) => { event.preventDefault(); if (!viewerID) { return; } const pendingSidebarInfo = createPendingSidebar( messageInfo, threadInfo, viewerID, + getDefaultTextMessageRules().simpleMarkdownRules, ); dispatch({ type: updateNavInfoActionType, payload: { activeChatThreadID: pendingSidebarInfo.id, pendingThread: pendingSidebarInfo, sourceMessageID: messageInfo.id, }, }); }, [viewerID, messageInfo, threadInfo, dispatch], ); } export { yearExtractor, yearAssertingSelector, monthExtractor, monthAssertingSelector, activeThreadSelector, webCalendarQuery, nonThreadCalendarQuery, useOnClickThread, useThreadIsActive, useOnClickPendingSidebar, };